package com.csthink.auditlog.config.aspect;

import com.csthink.auditlog.annotation.AuditLogging;
import com.csthink.auditlog.annotation.Change;
import com.csthink.auditlog.config.builder.BaseAuditLogBuilder;
import com.csthink.auditlog.config.builder.ChangeBuilder;
import com.csthink.auditlog.config.parser.AuditLogDetailParser;
import com.csthink.auditlog.domain.AuditLog;
import com.csthink.auditlog.domain.AuditLogEvent;
import com.csthink.auditlog.enums.ActionEnum;
import com.csthink.common.infrastructure.aspect.AbstractSpelAspect;
import com.google.common.collect.Lists;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.expression.EvaluationContext;
import org.springframework.http.ResponseEntity;
import org.springframework.util.CollectionUtils;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.function.Supplier;

/**
 * AuditLogging Annotation processor.
 *
 * Aspect for recording audit log for method marked with @AuditLogging.
 *
 * 1. Construct log from @AuditLogging's value;
 * 2. category, action, subaction is just plain text;
 * 3. Using spEL for accountId, detail, changes.id, changes.keyword,
 *    changes.oldValue, changes.newValue;
 * 4. detail's implementation is a little complex. It supports mixed grammar:
 *    a. expression in #{} will be parsed by spEL;
 *    b. others is just plain text;
 *
 * spEL variable support:
 * 1. It should inject spring's beanFactory, to support '@beanId' expr;
 * 2. It use '#argName' to get value from method's args;
 * 3. It use '#result' to get value from return value;
 * 4. It use '#mapping' to get value from value defined by @AuditLogging.mapping's;
 *    You can define a string, map, pojo, bean etc;
 * 5. It use '#before' to get value before method invoke;
 * 6. It use '#after' to get value after method invoke.
 *
 * Only record audit log when business logic does not throw Exception;
 *
 *
 * This processor will broadcast a AuditLogEvent with base audit log info to
 * spring ApplicationContext. Please use ApplicationListener to handle them.
 *
 * @author <a href="mailto:security.2009@live.cn">Mars</a>
 * @since 2023-09-12
 */
@Aspect
public class AuditLoggingAspect extends AbstractSpelAspect implements ApplicationContextAware {

    private final AuditLogDetailParser detailParser;
    private ApplicationContext applicationContext;
    private final Supplier<BaseAuditLogBuilder> builderFactory;

    @Value("${auditlog.log.auto.parse.empty:false}")
    private boolean logAutoParseEmpty;

    @Value("${common.dev_mode:false}")
    private boolean isDevMode;

    public AuditLoggingAspect(AuditLogDetailParser parser, Supplier<BaseAuditLogBuilder> builderFactory) {
        this.detailParser = parser;
        this.builderFactory = builderFactory;
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.applicationContext = applicationContext;
    }

    /**
     * Aspect advice
     *
     * @param pj
     * @param auditlogging
     * @return
     * @throws Throwable
     */
    @Around(value = "@annotation(com.csthink.auditlog.annotation.AuditLogging) && @annotation(auditlogging)")
    public Object logging(ProceedingJoinPoint pj, AuditLogging auditlogging) throws Throwable {
        EvaluationContext evalContext = getEvalContext(pj, true);
        // Parse "mapping"
        if (StringUtils.isNotEmpty(auditlogging.mapping()))
            evalContext.setVariable("mapping", evalSpel(auditlogging.mapping(), evalContext, Object.class));
        // Parse "before"
        if (StringUtils.isNotEmpty(auditlogging.before()))
            evalContext.setVariable("before", evalSpel(auditlogging.before(), evalContext, Object.class));
        Change change = auditlogging.change();
        ChangeBuilder changeBuilder = new ChangeBuilder();
        List idList = null;
        List keywordList = null;
        List oldValList = null;
        List<ChangeBuilder> changeBuilders = new ArrayList<>();
        try {
            // If change is a sequence, use List to hold them temporarily.
            if (change.sequence()) {
                // If it's a DELETE Action, we should handle it before business
                // logic execute. After execution, data has benn changed.
                if (ActionEnum.DELETE.equals(auditlogging.action())) {
                    idList = evalSpel(change.id(), evalContext, List.class);
                    keywordList = evalSpel(change.keyword(), evalContext, List.class);
                }
                oldValList = evalSpel(change.oldVal(), evalContext, List.class);
            } else {
                // Same as sequence's processing.
                if (ActionEnum.DELETE.equals(auditlogging.action())) {
                    changeBuilder.setId(evalSpel(change.id(), evalContext, String.class));
                    changeBuilder.setKeyword(evalSpel(change.keyword(), evalContext, String.class));
                }
                Object oldVal = evalSpel(change.oldVal(), evalContext, Object.class);
                if (null != oldVal) {
                    changeBuilder.setType(oldVal.getClass().getName());
                    changeBuilder.setOldVal(oldVal);
                }
            }
        } catch (Exception t) {
            // Handle all Exceptions during construct audit log.
            logger.warn("Got Exception before proceed during construct audit log: ", t);
        }
        // Do real job
        // If it throws a Exception, means change do not happen, so we
        // should not do audit.
        Date opStartTime = new Date();
        Object result = pj.proceed();
        Date opEndTime = new Date();
        try {
            // put "result" in Context
            if (result instanceof ResponseEntity) {
                evalContext.setVariable("result", ((ResponseEntity) result).getBody());
            } else {
                evalContext.setVariable("result", result);
            }

            // Parse "after"
            if (StringUtils.isNotEmpty(auditlogging.after()))
                evalContext.setVariable("after", evalSpel(auditlogging.after(), evalContext, Object.class));
            // Parse "condition"
            // When no condition spEL or result is true, do audit;
            // Else, when result is NULL or false, do not audit.
            if (StringUtils.isNotBlank(auditlogging.condition())) {
                Boolean condition = evalSpel(auditlogging.condition(), evalContext, Boolean.class);
                if (condition == null || Objects.equals(condition, Boolean.FALSE)) {
                    logger.debug("No need to do audit, cause condition matched.");
                    return result;
                }
            }
            if (change.sequence()) {
                if (!ActionEnum.DELETE.equals(auditlogging.action())) {
                    idList = evalSpel(change.id(), evalContext, List.class);
                    keywordList = evalSpel(change.keyword(), evalContext, List.class);
                    if(CollectionUtils.isEmpty(keywordList)){
                        String keywordStr = evalSpel(change.keyword(), evalContext, String.class);
                        keywordList = Lists.newArrayList(keywordStr);
                    }
                }
                List newValList = evalSpel(change.newVal(), evalContext, List.class);
                // guarding different length in id, keyword, oldVal, newVal
                int cbCount = notNullAndMax(idList, keywordList, newValList);

                // Construct ChangeBuilder
                for (int i = 0; i < cbCount; i++) {
                    ChangeBuilder cb = new ChangeBuilder();
                    if (checkThenGet(idList, i) != null)
                        cb.setId(idList.get(i).toString());
                    if (checkThenGet(keywordList, i) != null)
                        cb.setKeyword(keywordList.get(i).toString());
                    if (checkThenGet(oldValList, i) != null) {
                        cb.setType(oldValList.get(i).getClass().getName());
                        cb.setOldVal(oldValList.get(i));
                    }
                    if (checkThenGet(newValList, i) != null) {
                        cb.setType(newValList.get(i).getClass().getName());
                        cb.setNewVal(newValList.get(i));
                    }
                    if (!change.type().equals(Void.class))
                        cb.setType(change.type().getName());
                    changeBuilders.add(cb);
                }
            } else {
                if (!ActionEnum.DELETE.equals(auditlogging.action())) {
                    changeBuilder.setId(evalSpel(change.id(), evalContext, String.class));
                    changeBuilder.setKeyword(evalSpel(change.keyword(), evalContext, String.class));
                }
                Object newVal = evalSpel(change.newVal(), evalContext, Object.class);
                if (null != newVal) {
                    changeBuilder.setType(newVal.getClass().getName());
                    changeBuilder.setNewVal(newVal);
                }
                // If change.type is not null, always use this.
                if (!change.type().equals(Void.class))
                    changeBuilder.setType(change.type().getName());
                changeBuilders.add(changeBuilder);
            }

            BaseAuditLogBuilder builder = builderFactory.get();
            builder.category(auditlogging.category())
                    .action(auditlogging.action())
                    .operatorInfo(auditlogging.caller().getCallerName())
                    .subaction(auditlogging.subAction())
                    // detail is a template spel, which may contain #{}
                    .detail(evalTemplateSpel(auditlogging.detail(), evalContext, String.class))
                    .accountId(evalSpel(auditlogging.accountId(), evalContext, String.class))
                    .startAt(opStartTime)
                    .endAt(opEndTime);
            for (ChangeBuilder cb : changeBuilders) {
                if (cb.hasChange()) {
                    builder.addChange(cb.build());
                }
            }
            AuditLog auditLog = builder.build();
            // When log auto parse empty, do not try to do complex parse.
            if (!logAutoParseEmpty && !auditLog.hasDetail()) {
                auditLog.setDetail(this.detailParser.parse(auditLog));
            }
            // TODO This is a ugly hack, should be fixed ASAP.
            if (logAutoParseEmpty || auditLog.isWriteRecord()) {
                this.applicationContext.publishEvent(new AuditLogEvent(auditLog));
            }

        } catch (Exception t) {
            // Handle all Exceptions during construct audit log.
            logger.warn("Got Exception after proceed during construct audit log: ", t);
            if (isDevMode) {
                // eager to check message, hope to find missing message earlier
                throw t;
            }
        }
        return result;
    }

    /**
     * Get the max len(list), guarding null.
     */
    private int notNullAndMax(int current, List list) {
        if (null == list) {
            return current;
        }
        return (current == 0 || list.size() > current) ? list.size() : current;
    }

    private int notNullAndMax(List... list) {
        int max = 0;
        for (List l : list) {
            max = notNullAndMax(max, l);
        }
        return max;
    }

    private <T> T checkThenGet(List<T> list, int index) {
        if (list != null && list.size() > index) {
            return list.get(index);
        }
        return null;
    }
}
